Skip to content

fix(zarr-metadata): reject cast_value wrap on non-integer targets - #4380

Draft
d-v-b wants to merge 108 commits into
zarr-developers:mainfrom
d-v-b:zarr-metadata/rules-wrap-blosc
Draft

d-v-b wants to merge 108 commits into
zarr-developers:mainfrom
d-v-b:zarr-metadata/rules-wrap-blosc

Conversation

@d-v-b

@d-v-b d-v-b commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

🤖 AI text below 🤖

Depends on #4379, and stacked on it: this targets main, so its diff includes #4379's commits. Review only the last commit, or the diff against #4379's branch: d-v-b/zarr-python@zarr-metadata/rules-layer...zarr-metadata/rules-wrap-blosc

What this adds

cast_value's out_of_range: "wrap" is rejected for targets it is not defined on.

The spec permits wrapping only for integral targets with a two's complement representation. A modelled non-integer target reports invalid_value at out_of_rangebool included, since it is not two's complement. A target this reader does not model declines, because it may be an integral extension type:

wrap -> int32             accepted
wrap -> uint64            accepted
wrap -> bool              out_of_range 'wrap' requires a two's complement integer data_type
wrap -> float32           out_of_range 'wrap' requires a two's complement integer data_type
wrap -> r24               out_of_range 'wrap' requires a two's complement integer data_type, got 'r24'
wrap -> numpy.datetime64  out_of_range 'wrap' requires a two's complement integer data_type
wrap -> mycorp.bigint     accepted

Where the fact lives

Whether a type wraps is something the data type says about itself, twos_complement, and DataTypeEntity requires it. A data type that does not decide fails at import:

TypeError: Undecided does not declare twos_complement

Required rather than defaulted on purpose: either default answers for a new data type silently, and one of them accepts a cast the spec does not define.

That replaces what this rule carried before #4379 was rebuilt — two frozensets of data type names, plus a registry test asserting they partition every modelled type so that none could be accepted by omission. There is no longer a table to partition, so there is nothing for a test to keep in step.

Also here

Entities gain name, the spelling a document writes, as distinct from identifier, the key they are tabled under. The two differ only for the raw-bytes family, whose identifier is invented and belongs in no message a reader sees — this rule was reporting got 'r<N>' for a document that said r24.

🤖 Generated with Claude Code

@github-actions github-actions Bot added needs release notes Automatically applied to PRs which haven't added release notes zarr-metadata Specific to the zarr-metadata sub-package labels Sep 19, 2026
@d-v-b
d-v-b force-pushed the zarr-metadata/rules-wrap-blosc branch from f39ce0d to 59a1288 Compare September 19, 2026 19:43
@read-the-docs-community

read-the-docs-community Bot commented Sep 19, 2026

Copy link
Copy Markdown

d-v-b added a commit to d-v-b/zarr-python that referenced this pull request Sep 20, 2026
`typesize` is the only conditionally-required configuration member of any
entity this package models: "Positive integer specifying the stride in
bytes over which shuffling is performed. Required unless `shuffle` is
`"noshuffle"`, in which case the value is ignored." A TypedDict cannot
express that, so `BloscCodecConfiguration` declares it `NotRequired` and
nothing supplied the condition — leaving the member the spec singles out
as required as the one member of a blosc configuration that could always
be omitted, while `blocksize`, which the spec never marks required, was.

A rule supplies it, alongside the value constraints the shape validator
cannot state: `clevel` in [0, 9], `typesize` positive, `blocksize`
non-negative. blosc leaves the deliberately-rule-free list.

These rules were written for the stacked zarr-developers#4380, which is based on this
PR's pre-redesign head and would not apply to it; they are brought down
here because this is the PR that makes `typesize` optional, and zarr-developers#4380
should drop its blosc half when it rebases.

Verified against the spec's own example document and against what
zarr-python writes for blosc with and without shuffling.

Assisted-by: ClaudeCode:claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@d-v-b
d-v-b force-pushed the zarr-metadata/rules-wrap-blosc branch from 59a1288 to bde5d5d Compare September 20, 2026 12:20
@d-v-b d-v-b changed the title fix(zarr-metadata): reject cast_value wrap on non-integer targets and invalid blosc values fix(zarr-metadata): reject cast_value wrap on non-integer targets Sep 20, 2026
@d-v-b
d-v-b force-pushed the zarr-metadata/rules-wrap-blosc branch from bde5d5d to 7009a21 Compare September 20, 2026 12:26
d-v-b and others added 21 commits September 20, 2026 14:32
…dators, create_* factories

Adds `zarr_metadata.rules`: cross-field judgments over whole documents
(fill value vs data type, codec pipeline ordering, chunk-grid geometry,
sharding and transpose against the array each codec actually receives,
struct field rules, dimension-name counts), registered where they are
defined and dispatched per entity; `validate_*` / `is_*` / `parse_*` /
`check_*` front doors for readers; `v3._shape` validators derived from the
entity TypedDicts; and one `create_*` factory per document TypedDict.

The two rank checks (v3 `dimension_names` vs `shape`, v2 `chunks` vs
`shape`) move from the structural validator into the rules layer. The
pydantic field types now run the rules layer before normalizing, so they
are strictly stronger than before rather than weaker.

Relative to #296 this drops the incremental builder, the
extension-point provenance table, `TypeIs` codec guards, fill-value
propagation through codec chains (no rule read it), registry
introspection helpers, and a duplicated v2 consolidated envelope check;
renames the `.zarray`/`.zgroup` factories to `zarray`/`zgroup`; and
requires an endianness on the `bytes` codec inside `index_codecs`.

Split from #296 (part 2 of 3).

Assisted-by: ClaudeCode:claude-fable-5-1
Assisted-by: ClaudeCode:claude-fable-5-1
…eate_* factories

Upstream enabled ruff S101 for runtime code (zarr-developers#4363). The six
parse-then-raise factories share a _parsed_or_raise helper that narrows
the parsed document, and the rule-registration import uses
importlib.import_module instead of an assert to keep it referenced.

Assisted-by: ClaudeCode:claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…4379

Assisted-by: ClaudeCode:claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The builder is a construction feature in a read-side PR, and its
factories re-implemented the structure-plus-composition combination this
PR already exposes as `rules.validate_*` / `rules.parse_*`. Held back for
a later PR alongside the incremental builder.

`test_validator_and_factory_agree` becomes
`test_validator_and_parser_agree`: the same property against the front
door the package keeps.

Assisted-by: ClaudeCode:claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A fourth read-side front door for a question `validate_*` and `parse_*`
already answer, with two new public types (`Valid`, `Invalid`) and no
consumer. Removed with its tests and changelog fragment.

Assisted-by: ClaudeCode:claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `is_*` counterparts were `bool`, not `TypeIs`, and each docstring had
to explain that they do not do what the name promises; `model.is_*`
remains for narrowing. Their removal leaves `validate_*` and `parse_*`,
which now share `_judged` over an already-normalized document instead of
walking it twice per call.

Assisted-by: ClaudeCode:claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- `initial_spec` was a four-line tail of `chain_initial_spec` living in
  another module; folded into its only caller.
- `STORAGE_TRANSFORMERS` was defined and exported without a reader.
- `_engine`'s prior-art bibliography condensed to the claim it supports.

Assisted-by: ClaudeCode:claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The struct spec states its field constraints in the bytes codec's terms
rather than inventing its own: field types must have "a fixed encoded
size", and "When a `struct` type contains multi-byte numeric fields, the
`bytes` codec MUST be configured with an explicit `endian` setting". So
the struct rule's fixed-size question and the bytes rule's endianness
question are one classification, and the two hand-written tables of
data-type sizes were the same table twice.

`rules._storage_class` now owns it, keyed off the data-type modules' own
name constants, and `test_registry_drift` fails if a new data type
arrives without a class — previously it would have silently gone
unjudged by both rules.

Assisted-by: ClaudeCode:claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The strict reading was already the behavior; the prose hedged, telling
callers they "can filter" a kind that every raising path rejects. State
the decision instead: an unmodelled member of a known entity's
configuration is almost always a typo or a setting meant for a different
entity, so `parse_*` and the pydantic field types both refuse it, and the
dedicated kind is there for triage via `validate_*`.

Two tests pin what was untested: that the pydantic field types run the
rules layer at all, and that they reject an unknown configuration member.

Also syncs the README and docs feature lists, which still described the
pydantic integration as delegating to the model parser and omitted the
rules layer entirely, and notes that the generated JSON Schemas leave
configurations open, so schema-valid input can still fail at runtime.

Assisted-by: ClaudeCode:claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Drop a `pyright: ignore[reportPrivateUsage]` on a public name; pyright
  is configured for `src` only, so the comment was never evaluated.
- Keep `Sequence` under TYPE_CHECKING alongside `Callable` and `Rule`.
- Rewrap the changelog fragment and say "two validation layers", so it
  does not read as contradicting the README's three-layer overview.
- "different entity", consistently, in the pydantic strictness test.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The previous wording — "every entry point that raises rejects it, the
pydantic field types included" — is false. Three raising entry points
accept an unmodelled configuration member: `model.parse_*`,
`ZarrV3ArrayMetadata.from_json`, and the bare `ZarrV3MetadataField`
pydantic type. Only the rules layer judges configurations, so only
`rules.parse_*` and the whole-document pydantic field types reject it.

Say that instead, in the README, the docs site, the `ProblemKind`
docstring and the changelog fragment, and assert the `ZarrV3MetadataField`
half of the boundary so it cannot move unnoticed.

Assisted-by: ClaudeCode:claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by adversarial review; each has a regression test.

- scale_offset registers the identity spec transition the spec describes
  ("MUST be performed using the arithmetic semantics of the input array's
  data type"; astype was removed in v3 in favour of cast_value). Without
  it a no-op codec stopped propagation and stood down every later rule.
  The test exemption that recorded this is gone.
- chain_initial_spec keeps the rank when it cannot keep the extents: an
  ArraySpec extent may now be None individually, so a rectilinear grid or
  a zero extent no longer hides a rank-mismatched transpose or shard.
- Entity rules declare the configuration members they read, and
  run_entity_rules stands down only the rules that read an unusable
  member rather than the whole entity. This also makes the invariant the
  configuration["member"] accesses rely on explicit and checked at
  registration, where it was previously true only by inspection.
- A rule reporting at the entity itself keeps that location instead of
  being re-based under a "configuration" node a bare-string entity does
  not have.
- The endianness problem names the data type, so the shard-index case
  reads as uint64 rather than appearing to contradict the document.

Assisted-by: ClaudeCode:claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sking

Two defects in the per-member gate added in 08d335d, both found by
re-running the adversarial review against it.

`run_entity_rules` decided "the configuration is unreadable" from the
*length* of a problem's location, so `("must_understand",)` qualified and
a malformed envelope flag stood down every composition rule for that
entity. Test the two locations that actually mean it instead.

`reads` promised that declaring a member makes `configuration[member]`
safe, but it was validated against every modelled member rather than the
required ones. A rule declaring an optional member still raised KeyError
out of validate_*, which must never raise. `reads` now accepts only
required members; `reads_optional` covers the presence-tested case (the
bytes codec's `endian`), and registration refuses the unsafe spelling
with a message naming the alternative.

Assisted-by: ClaudeCode:claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three sites derived "the shape of the chunk this pipeline encodes" by
hand — the document's grid, a shard's inner chunk shape, and the shard
index — and each got a different subset of the reasoning right. Patching
them one at a time produced three near-identical fixes and missed two
more cases, so model the thing itself.

`rules._chunk_grid` answers the question per dimension instead of per
grid: a `GovernedShape` has one entry per axis, `None` where the chunks
differ or the metadata cannot be read, and is itself `None` only when not
even the rank is known. Following zarrs, a grid is read from its metadata
together with the array shape it partitions (neither determines a grid
alone), its rank is always available, and its extents are reported
dimension by dimension rather than as one shape.

Two cases the previous derivations could not express now work. A
rectilinear grid whose chunk shapes are uniform pins the shard shape, in
all three spellings the spec allows, while one uniform on a single axis is
judged there and declines elsewhere. A shard index is judged against
chunks-per-shard plus a trailing dimension of 2, as the spec derives it,
instead of against nothing.

Also extracts entity configuration access into rules._entity so the grid
module can read a grid's configuration without importing the dispatcher.

Assisted-by: ClaudeCode:claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A codec pipeline encodes one chunk, but the same pipeline encodes every
chunk, so a chain rule is a statement about all of them. The propagated
value said otherwise: `ArraySpec` held one chunk's shape, so an axis whose
chunks differ collapsed to "unknown" and the rules that could have judged
it stood down.

`ArrayParts` carries the `ChunkGrid` instead. A grid holds its rank, its
metadata as written, and per dimension the set of lengths that dimension's
chunks take — singletons for a regular grid, `{30, 34}` for a rectilinear
axis, `None` only where nothing can be read. Divisibility quantifies over
that set and names the length that fails.

This catches a case no previous shape could express: a rectilinear grid,
a transpose moving its varying axis, and a sharding codec that has to
divide what arrives. Inner extents of 15, 30 and 34 against chunks of 30
and 34 were all accepted before; only a common divisor passes now.

Two things fall out of holding the grid rather than a projection of it.
Sharding stops being a special case — a shard is a nested array, so its
inner pipeline is built by the same constructor as the document's own —
and a third-party grid is carried verbatim instead of being flattened at
the first hop, so a future rule can read its own configuration.

`data_type` becomes non-optional: the only documents that cannot supply
one are documents the structural layer has already rejected, so rather
than a half-populated value, a codec that can no longer be described
receives nothing at all. `NOTHING_KNOWN` is gone in favour of `| None`,
and rules test one guard instead of a field at a time.

Named for what it is, after zarrs: a grid is built from metadata *and* the
array shape it divides (neither determines a grid alone), its
dimensionality is total, and its edge lengths are reported per dimension.
`ArrayParts` avoids `chunk`, which would be singular, and `ArraySpec`,
which is taken by zarr.core.array_spec for the runtime type.

Assisted-by: ClaudeCode:claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`st.from_type` resolves the codec TypedDicts unaided — ReadOnly, closed,
NotRequired and Literal all work — once one strategy is registered for the
recursive JSONValue alias, without which sharding_indexed, cast_value and
scale_offset cannot be resolved at all.

The existing totality test feeds arbitrary JSON to the document
validators. That is the right guard for the structural layer and no guard
for this one: a random object never names a codec, so across 5,000
examples it dispatched no entity rule. Drawing codecs from their own types
and assembling the chain by kind takes documents reaching a chain rule
from 0% to 94%; ordering is what does it, because a misordered chain is
rejected before any other rule runs, and a flat list is misordered most of
the time.

Adds the property nothing covered: documents valid by construction, with
inner chunk shapes drawn from the divisors of the extents they must
divide, over both regular and rectilinear grids. Every recent fix made
this layer stricter and under 5% of generated documents are valid, so the
accept side had no generated coverage at all.

Each strategy's reach is asserted, not assumed — the witnesses it must
produce, and why the three chain rules it cannot reach are out of reach by
construction. Verified by sabotage: stubbing out entity dispatch fails
both reach tests, where previously the property suite stayed green.

Assisted-by: ClaudeCode:claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ck tests

Differential testing against the pre-ArrayParts tree found 390 documents
the old validator rejected and the new one accepted. All of them are one
regression: gating the shard's inner and index pipelines on `incoming`.
Both are determined by the sharding codec's own `chunk_shape` and by the
spec — the index is a `uint64` array whatever reaches the codec — so
neither should ever have waited on upstream. It was a consequence of
making `data_type` non-optional: with nothing to pair the grid with, both
starts collapsed. The reasoning behind that change held for documents and
failed inside a shard, where the grid is known and the element type is
not, so `data_type` is optional again while `ArrayParts | None` keeps its
own meaning of "no array here".

Also: an unusable `data_type` costs itself and no longer hides the
geometry, and `ChunkGrid.permuted` declines on a non-permutation instead
of raising `IndexError` one careless caller away from a validator.

Tests are now type-checked. They were covered by nothing — pyright was
configured for `src` alone and the repo's mypy hook resolves this
package's imports as `Any` — which is how a `TYPE_CHECKING` import of a
type deleted three commits ago survived. It also showed that
`from_key_value(to_key_value())`, the round trip the models advertise, did
not type-check: `Mapping` is invariant in its key type, so a mapping keyed
by literal store keys is not a `Mapping[str, bytes]`. Widened.

A mutation audit put the suite's kill rate at 67%, with the largest hole
in the `reads` gate: `st.from_type` honours the TypedDicts, so no strategy
produced an ill-typed configuration member, and four one-token changes to
the gate made `validate_*` raise while the suite stayed green.
`corrupted_chains` covers it; the three mutations were verified to fail
now. Two redundant property tests are gone, and the reach claim in the
strategies module is replaced with measured figures — the previous 39%
to 94% and its short-circuit explanation did not reproduce.

Assisted-by: ClaudeCode:claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`typesize` is the only conditionally-required configuration member of any
entity this package models: "Positive integer specifying the stride in
bytes over which shuffling is performed. Required unless `shuffle` is
`"noshuffle"`, in which case the value is ignored." A TypedDict cannot
express that, so `BloscCodecConfiguration` declares it `NotRequired` and
nothing supplied the condition — leaving the member the spec singles out
as required as the one member of a blosc configuration that could always
be omitted, while `blocksize`, which the spec never marks required, was.

A rule supplies it, alongside the value constraints the shape validator
cannot state: `clevel` in [0, 9], `typesize` positive, `blocksize`
non-negative. blosc leaves the deliberately-rule-free list.

These rules were written for the stacked zarr-developers#4380, which is based on this
PR's pre-redesign head and would not apply to it; they are brought down
here because this is the PR that makes `typesize` optional, and zarr-developers#4380
should drop its blosc half when it rebases.

Verified against the spec's own example document and against what
zarr-python writes for blosc with and without shuffling.

Assisted-by: ClaudeCode:claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"An integer from -131072 to 22 which controls the speed and level of
compression", with 0 selecting the default. The shape validator can only
say it is an integer, so the range is a rule, and zstd leaves the
deliberately-rule-free list alongside blosc.

`checksum` needs none: the spec marks it "(Optional)" and the TypedDict
already declares it `NotRequired`. Its "Should be omitted if false" is a
SHOULD, and this package reports violations of requirements rather than
of advice.

Assisted-by: ClaudeCode:claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
d-v-b and others added 29 commits September 21, 2026 15:22
…ot a record

The `Compiled` record, its weak side table and the `compiled()`
accessor were memoization dressed up as architecture. What the layer
reads off an entity's fields -- `member_types`, `nested_members`,
`name_members` -- is three functions of the class under
`functools.cache`, and `configuration_required` is a one-line function
over the first.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the fields as it reads

Review question: why is anything defined outside the individual
classes? The entity declares its fields and one `__post_init__`; the
base's `coerce` type-checks a document's configuration against those
fields as it reads them, member by member, resolving the ones that
hold entities through the scope, and builds the entity only when every
member read. That needs no table: `member_types`, `nested_members`,
`name_members`, `configuration_required`, `derive_member_types`,
`coerce_members` and the `MemberTypes` alias are gone, along with the
cache that held them. The invariants read the same field hints; the
first of them refuses a field annotation outside the shapes the
compiler reads, with the message that named the table's failure before.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… names its fields

Review question: why is `ExtensionPointField` defined outside the array
document's validation logic? It was the array document's field names,
carried into the entity layer as the key of every registry table, the
`extension_point` class variable on every kind, and five constants.
That knowledge belongs to the document. Now a scope holds entities by
kind -- `DataTypeEntity`, `ChunkGridEntity`, `ChunkKeyEncodingEntity`,
`CodecEntity`, `StorageTransformerEntity`, the last two new, so every
entity is of one -- and `read_array_v3` is the one place that says
which field holds which kind.

`Context.of(*classes)` and `extended_with(*classes)` read each class's
kind off its base and its key off its `identifier`, so nothing can be
misfiled and the two registration checks for that are gone; `resolve`
and `coerce` take the kind (or any subclass, so a field typed
`GzipCodec | Opaque` resolves only gzip) and are generic in it, which
retires four overloads apiece and the casts that funnelled the
document's fields through one dict. The typed-per-point TypedDicts,
`_ENTITY_KINDS` and `_point_of` go with them.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…teral of its own JSON type

Review comment: a base `to_json` that builds a `dict[str, object]` and
casts it to a type parameter it cannot know is wrong. It is abstract
now. Each of the 33 entities writes its own, as a literal of the
TypedDict it names -- the bare name when every member is absent, the
object otherwise, a contained entity through `written` -- and pyright
holds the literal to the TypedDict: a key it does not declare, a
required one left out, a value of the wrong type is a static error.

Gone with the cast: the class-creation check that compared the named
type with the fields and its helpers, `json_type_of`, the generic
`configuration()` and the rendering walk it needed, the `must_understand`
class variable nothing read any more, and the test oracle that compiled
the named type -- the examples table now round-trips each document
through `coerce` and `to_json` instead. `ArrayDocumentV3.to_json` writes
its five fields by name.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An entity names its JSON type once, as the return type of its own
`to_json`, and pyright holds the literal to it. Naming it a second time
as a type argument of the base bought nothing: the base's `to_json` is
abstract, so no method on it ever needed the parameter, and every bare
use of a kind (`CodecEntity | Opaque`, a scope's table) wanted the
defaulted form anyway. `Generic[JSONT_co]`, the two `TypeIs` helpers the
generic base needed to narrow an `object`, and `unsubscripted` go with
it; the 38 subscripted bases are plain again.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e nested walks are the entity's own

The type-annotation reader is one module, `_typed_json`, that knows
nothing of entities: `parser_for` turns a field annotation into a
parser of JSON values, over the shapes JSON takes and no others. A
caller with a shape of its own passes a `leaf`, asked first at every
depth. The entity layer's one shape is a field holding another entity,
`Kind | Opaque`; its leaf reads the inner entity through the scope and
keeps the inner problems apart from the containing entity's own. So a
struct's fields' data types and a shard's pipelines are resolved by the
same walk that type-checks them, and the three annotation walkers the
entity module carried for resolution and canonicalization, with the
union-branch and fixed-tuple machinery they needed, are gone.

`canonical` is the entity's own, as `to_json` is: the entity itself by
default, overridden where two spellings mean the same and, in an entity
that contains entities, to put those in canonical form with
`canonicalized`. No base walk, no `simplified` hook, no `@final`.

Class creation refuses two things: a field whose annotation is not a
shape JSON takes, and a class variable a base annotates and nothing
sets. The other nine invariants either duplicated pyright (a field
shadowing a class variable, a `Literal` class variable outside its
values, an override of a final method), duplicated registration (an
entity of no kind; a codec skipping the kind classes, now refused
there), or guarded defaults `coerce` no longer relies on: an optional
member the document left out is passed as `UNSET` explicitly. A codec's
pipeline position is its base class alone; the `kind` string it also
carried is gone, and the chain rules ask `isinstance`.

One diagnostic is more complete: when one element of a shard's pipeline
is not a metadata field at all, the other elements are still read and
judged, where before the whole member stood down. Over the 40,000
document corpus that adds 133 problems to 91 documents already invalid
and changes no verdict; nothing is lost.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ance that yields problems

Every check that lived in an entity's `__post_init__` is a module-level
function of the instance -- `blosc_problems(codec)` -- that yields each
problem as it finds it, bound on the class as `problems`. One function,
two consumers: the constructor takes the first problem it yields and
raises `MetadataValidationError`, so `BloscCodec(clevel=99)` still
refuses; `coerce` runs it to the end and reports every problem in the
document. Anyone holding an entity may run it too, and stop or collect
as they need.

`coerce` builds the instance without asking the constructor -- the
members are already parsed and typed -- and asks `problems` itself, so
a document's every problem is reported where the constructor would
stop. Class creation refuses an entity that defines `__post_init__`,
because nothing would run it on a document. The two numpy time types
declare their shared configuration and rule on the family. No verdict
or problem changes over the 40,000-document corpus.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three reviews of the rebuilt layer, each from the vantage of another
implementation (zarrs, TensorStore, zarrita.js), agreed on a short list
of defects and complexity; this is the part that needed no decision.

Every codec declares `variable_size`. The default of `False` was a
verdict: the door's own example compressor, which set nothing, was
accepted as a shard-index codec, where `gzip` is refused.

Registration is the one moment an entity is refused. `__init_subclass__`
and the twelve `base=True` flags are gone; `_registrable` asks
`unreadable`, which names a field whose annotation is not a shape JSON
takes (a type defined inside a function among them, now a message
rather than a bare `NameError`), a `__post_init__` of the entity's own,
and an owed class variable left unset, before the abstract methods.
Nothing happens at class creation, so a family is a plain subclass and
a forward reference resolves.

The envelope is judged once. `Context.coerce` runs the metadata-field
check, reports it only when the model layer has not, and does not read
an entity whose value names none or whose configuration is not an
object; the entity no longer adds "expected a metadata field" beside
the model's report of the same defect, nor "requires a configuration"
beside "expected a mapping". `ArrayDocumentV3.from_json` reads whatever
the structure allowed, so a structural problem no longer hides the
semantic ones. A rule about the member the envelope's name carries
lands on the field, not under a `configuration` the document does not
have. A name resolves by asking each entity of the kind whether it is
its own, in registration order; the invented-identifier veto is gone.

`Opaque` answers `to_json` and `canonical` -- the JSON it kept, and
itself -- so `written` and `canonicalized` are gone and a field typed
`CodecEntity | Opaque` is written and simplified without asking which
it holds.

In the parser: `Literal[1]` refuses JSON `true` (`True == 1` in
Python); a `Literal` of mixed types sorts by repr instead of failing on
a comparison; a fixed-length array accepts a list as a homogeneous one
does; a key inside a nested object is reported at the key, as a
top-level one is; a record dataclass that refuses its own values is
reported, located under the object, rather than raised out of `coerce`.

Over the 40,000-document corpus, no verdict changes. Lost: 18,067
duplicate reports of a malformed envelope, 1,263 "requires a
configuration" beside "expected a mapping", 340 judgments of an entity
whose configuration was not an object (it was judged as having none),
and 42 nested-key locations, gained back at the key.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… is an argument

A parser took the scope it read nested entities in through a closure,
so nothing compiled could outlive one read: every read resolved the
class's annotations and compiled every field's parser again, and a
document read cost eighty times a `json.loads` of the same bytes.

The parser now takes the reading it runs in as an argument, generic in
what the caller passes -- `Parser[S]` is `(value, loc, state) ->
parsed` -- and hands it down untouched to the parsers it is built from.
The entity layer's one shape, a field holding another entity, reads
its scope and its problem sink off a `_Reading`. So a class's plan --
each field's parser, whether it is optional, whether the envelope's
name fills it -- is a pure function of the class, compiled once and
cached with `functools.cache`, as the class's resolved annotations now
are. The scope's `coerce` also stops re-judging an envelope the model
layer has judged.

Measured on a document with a shard, six codecs and a nested pipeline:
199 µs to 85 µs per read; one codec, 21 µs to 6 µs. The 40,000-document
differential is unchanged to the problem.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…hecked`

The constructor is the checked way to build an entity and stops at the
first problem; `coerce` builds the record without the check and asks
`problems` for every one. That second way was a private `_unchecked`
taking a mapping. It is now `create_unchecked(**members)`, a documented
classmethod, for any reader that judges afterwards and wants every
problem of a hand-built entity -- the pair pydantic spells `__init__`
and `model_construct`.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`writer_for` is the parser's inverse over the same field annotation:
what `coerce` reads from a document, `to_json` puts back. The base
writes every entity -- the bare name when every member it holds is
absent, the object otherwise, a contained entity through its own
`to_json`, a JSON-valued member copied -- and the 34 hand-written
`to_json` methods, each an instance of that rule, are gone, along with
the 26 in the tests and the one in the door's example. An entity whose
JSON is not its fields may still override; none does.

What is given up is the per-entity return type. Every `to_json` is
typed as the metadata field union rather than as the entity's own
TypedDict, so a consumer who wants a member of the written form typed
narrows it, as the tests now do through their helpers. The public
`*Configuration` TypedDicts stay as the JSON types, and one test ties
each to its entity's fields -- same keys, same requiredness -- which
nothing did before.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The metadata is `{name, configuration}`, and the entity now has that
shape: the name is the class, and its one field, `configuration`, names
a frozen dataclass of the members. The lift of configuration keys to the
entity, which nothing in Python expressed, is gone with the per-member
loop in `coerce` that did it: the configuration is parsed against the
record by the parser's record shape, and written back by the writer's.
Each entity lifts its members back to the top level with a `@property`
per member, and `with_configuration(**changes)` is the entity with
members of its configuration replaced, checked as any construction is.

Hand construction reads `GzipCodec(GzipOptions(level=5))`. Every
record is named `<Entity>Options`, since `<Entity>Configuration` is the
public JSON TypedDict; the name is a placeholder. The 40,000-document
differential is unchanged to the problem.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A configuration record subclasses `Configuration`, and the rules on its
members are its `problems` method, yielding each as found:
`BloscOptions(...).problems()` answers without an entity. The entity's
constructor stops at the first; `coerce` asks the record before it
builds anything and reports every one, so `create_unchecked` and the
`object.__new__` behind it are gone. A family's rule about its name --
`r<N>` a multiple of 8 -- is the entity's `name_problems(name)`, a
classmethod, since a name is not configuration. The module-level rule
functions, their quoted forward references and positional-only markers,
and the `problems = fn` bindings go with them. No verdict or problem
changes over the corpus.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… a configuration

An entity whose metadata carries a configuration adds `Configured`
beside its kind -- `class GzipCodec(BytesBytesCodec, Configured)` --
which declares the `configuration` field as a `Configuration` and is
what the base branches on: the plan, the constructor's check, `coerce`
and `to_json` ask `issubclass(cls, Configured)` rather than whether a
field happens to be there, and read the record as a typed attribute
rather than through `getattr`. `with_configuration` lives on it, since
only such an entity has members to replace. The entity narrows the
field to its own record, which pyright accepts for a frozen dataclass.
Registration refuses a `configuration` declared without the marker.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ound

Four reviewers (correctness, extension author, simplicity, zarrs and
TensorStore parity) read 764cdc9. The corrections that need no
decision:

- A pipeline the document did not write as an array was read as an
  empty one and judged "no array->bytes codec" beside the structural
  problem. Nothing was read, so nothing is judged. On the 40k-document
  corpus that is the whole difference: 4,262 occurrences of that one
  message lost, every one on a document whose `codecs` is absent or not
  an array and which keeps its structural problem there; 0 verdict
  flips, 0 problems gained.
- A name in scope but of another kind -- `transpose` where a
  `BytesBytesCodec` is asked for -- came back `out_of_scope`, for a
  reader to resolve elsewhere. It is `invalid`, with a problem that
  names the kind it is.
- The checker: a `Literal` of booleans had the shape of integers, and a
  `Literal` mixing shapes had one it did not, so a union could send a
  value to the wrong branch; describing a mixed `Literal` crashed in
  `sorted`; `is_class_var` missed `t.ClassVar` under PEP 649;
  `field_hints` handed out its cached dict; a fixed tuple of the wrong
  length raised `zip`'s error rather than the JSON one.
- Registration refuses two more things, with a message: a member that
  is itself a `Configuration`, whose rules nothing would ask, and a
  `__post_init__` on a record, which would stop at the first problem
  where `coerce` reports every one. A `NameError` inside a nested record
  gets the message an entity's gets.
- `Extents` was a string alias, so `Extents | None` raised in an
  extension's annotation.
- The `*Options` records are exported from their modules, since hand
  construction needs them; the public-name grammar learns the role.
- Prose. The door's example is complete and runs as written, and says
  what `transition` may return, what `storage_class()` answers, how a
  family is written, and that only `| UNSET` makes a member optional to
  a document. The boundary paragraph of 4379.feature.7 described a
  fallback the branch does not have: an ill-typed optional member stops
  the entity, as the tests say, and what it contains is still read.
  `canonicalize_array_metadata_v3` says that a canonical form breaking
  its own rules raises, since the document was valid. Stale names
  (`blosc_problems`, `raw_bytes_problems`, `canonicalized`,
  `__post_init__` as the place for rules) are updated, and a duplicate
  import in `struct` is gone.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…was written

`to_json` claimed fidelity and `canonical` claimed to be the one
transformation, but `to_json` already canonicalized the envelope: an
object around a bare name, an empty `configuration` and a
`must_understand` of `true` all came back simplified, while an `Opaque`
kept them verbatim. zarr-python writes `{"name": "crc32c"}` into every
sharded array, so a read-then-write respelled nearly every file.

An entity on its own still writes its own spelling, since it has no
document to be faithful to. `ArrayDocumentV3.to_json` now walks the
document it read beside what its entities write and puts each envelope
back as the document spelled it, at any depth, so a document read and
written comes out as it went in; what changed is what changes, and an
entity put in by hand writes itself. `canonical()` is the one
transformation: it rewrites the stored document into each entity's own
spelling, so writing a canonical document is the identity.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…red is gone

`Configured` marked the entities that take a configuration, and the
layer branched on it in five places, with a registration refusal to
keep the marker and the annotation saying the same thing. The spec
makes an absent configuration and an empty one the same, so the branch
was never about the metadata: every entity holds a `Configuration`,
and a bare-name entity holds the empty one.

`MetadataEntity` declares `configuration: Configuration`. An entity
with members narrows it to its own record, its one positional
argument; an entity of a bare name defaults it to the empty record
with `field(default_factory=Configuration)`, keyword-only where a
carried name is the positional argument. The plan reads the record or
nothing, `coerce` builds every entity the same way, and
`with_configuration` lives on the base and refuses an unknown member
the way `replace` does. Pyright still catches a `configuration`
narrowed to the wrong thing, now for every entity.

The base field has no default because pyright refuses a positional
child field over a defaulted base field. The default therefore lives on
the bare entities, where a forgotten one is a missing argument that
pyright reports at the call.

Unchanged to the 40k-document corpus: 0 verdicts, 0 problems.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ty lifts are gone

Every configuration member was declared three times: a field on the
record, a key on the public TypedDict, and a `@property` on the entity
that returned the field. The property carried no information -- pyright
cannot derive it, so each was hand-written -- and seventeen of the
twenty-eight were read by nothing in the package. The one read path is
now `codec.configuration.level`, the shape the metadata has, and an
entity's own methods read `self.configuration.x` the same way. The
worked extensions in the tests drop their lifts too, so the door shows
one pattern.

Unchanged to the 40k-document corpus: 0 verdicts, 0 problems.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ong type

Until now no construction path checked types at runtime. Pyright was the
type checker, and `with_configuration(**changes: object)` is the one
path it cannot see through: `codec.with_configuration(level=5.0)` built
an entity whose rules passed and whose document a reader would refuse.
`replace` on a record was the same hole with a longer name.

`Configuration.__post_init__` now checks every member against its
annotation with the parsers a document is read by, given a leaf for
Python values: a member typed `Kind | Opaque` holds an instance of the
kind or an `Opaque`, a member typed as a record holds an instance of it,
checked in turn, and an optional member may be `UNSET`. An unknown key
in a mapping member is the reader's report, not a type, and passes as
`coerce` lets it pass. `with_configuration` needs no code of its own:
`replace` rebuilds the record through that constructor and the entity
through its own, so every path checks types and values.

The parser builds the record through the same constructor, so a
document read checks types twice. Measured in one process, an unchecked
builder for the parser saved 8% of a document read and 15% of a single
codec, and was dropped: not a big enough win to be worth an unchecked
way to build a record.

Unchanged to the 40k-document corpus: 0 verdicts, 0 problems, 0 crashes.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…built value checks itself

The annotations were meant to be the type judgment, and they were,
for a document. Built by hand, nothing at runtime held an entity to the
record it declares: `GzipCodec(BloscOptions(...))` built, and wrote
blosc's members under gzip's name. The entity's constructor now refuses
a record that is not its own and a carried name that is not a string,
before any rule reads them.

The other values the reader builds get the same treatment, so a hand
built one is what it says: an `Opaque` refuses a reason the reader
does not give, and `ArrayDocumentV3` refuses a field holding anything
but an entity of its kind or an `Opaque`, located at the field. One
function answers "is this an entity of that kind, or an Opaque" for
records and documents alike.

Unchanged to the 40k-document corpus: 0 verdicts, 0 problems, 0
crashes. A document read now costs about a tenth more than before the
runtime type checks existed, all of it checks that did not exist.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ng checked

The record's and the entity's constructors check types and rules, which
is right for anything built by hand and redundant for the reader: the
parser has type-checked every member against the same annotations, and
`coerce` has run the rules, before either builds. A document read paid
for each check twice.

`create_unchecked(**fields)` on `Configuration` and on `MetadataEntity`
is the one way around the constructors, named for what it is: a caller
that has just made the checks builds through it, and nothing else does.
The parser builds records through it and `coerce` builds entities, so a
read makes each check once. A document read costs what it did before
any runtime check existed.

Unchanged to the 40k-document corpus: 0 verdicts, 0 problems, 0 crashes.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he class owns its routine

The scope was an object with a reading of its own, `Context.coerce`,
beside the class's `coerce`, and the two judged the same field
differently. The scope is now a value with one question, `claimant(kind,
name)`: which class in scope a name belongs to. `resolve(data, kind,
context)` is the reader. It relates the identifier in the field to a
concrete class through the context, judges the envelope once for every
field, and hands the class the field, since the class owns its
validation routine. That routine, `coerce`, says what it is: the
configuration parsed and the rules asked, the envelope being the field's
and `resolve`'s.

Unchanged to the 40k-document corpus: 0 verdicts, 0 problems.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…the resolved pipeline

Reading a v3 array document is now three explicit layers, ordered by
what each needs and each handing the next a typed value with its
problems.

`well_formed_array_v3(value)` needs only the value: `refine_json` turns
it into JSON with arrays as tuples, string keys and finite floats in one
walk, or says at which leaves it is not, and the model layer judges the
document's shape. Nothing downstream normalizes or checks JSON-ness
again: the `envelope_judged` flag and the second normalization inside
`coerce` are gone, `Opaque.json` is `JSONValue` and checked, and the
class routine takes refined JSON.

`read_array_v3(document, context)` needs a scope: each extension point
is handed to `read_field`, which is what `resolve` does once a field's
envelope is judged. `resolve(data, kind, context)` is the first two
layers for a field on its own.

`refine_array_v3(array)` needs the array, and returns `RefinedArrayV3`:
the `ArrayParts` the pipeline is handed and a `Pipeline` of
`PipelineStage`s, each a codec and the array that reaches it, a shard's
`codecs` and `index_codecs` refined inside its stage. This is what a
codec pipeline is built from; validating the composition is what the
walk finds on the way. A codec that holds pipelines declares them
through `inner_pipelines(incoming)` and judges nothing inside them
itself, so the sharding codec's own walk is gone with `chain_problems`.

Unchanged to the 40k-document corpus: 0 verdicts, 0 problems, 0 crashes.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…te number

zarr-python writes attributes with Python's `json` defaults, so a NaN
`_FillValue` or CF `missing_value` is stored as a bare `NaN`. Every
layer refused such a document, and the store reader could not decode
it, so the consumer this layer is for could not read its own stores.

`refine_node_json` is `refine_json` for a node document: a non-finite
number is the float it is inside `attributes`, including the nodes a v3
group's inline `consolidated_metadata` holds, and not JSON anywhere
else. The documents' first layer and the rules door read through it;
`_validate_attributes` and the `is_*` guards agree with it. The store
reader and writer judge what may be stored at a key by what the key
stores (`stored_json_problems`): a `zarr.json` is a node document, a
`.zattrs` is user data, a `.zmetadata` holds documents keyed the same
way, a `.zarray` or `.zgroup` holds none. The reader now locates a
non-finite number where it may not be instead of failing to decode, and
the writer still refuses one, since a model built by hand is not
validated. `is_json`, `validate_json` and `refine_json` stay RFC 8259.

Unchanged to the 40k-document corpus: 0 verdicts, 0 problems, 0 crashes.

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… understand

The spec has a reader refuse an array carrying a top-level field it
does not recognize unless the field says `must_understand: false`. The
model partitions by that obligation (`must_understand_fields`) and
leaves recognition to the reader; the read document offered no way to
ask, so a consumer had to walk the raw document. It now has the same
property.

A verdict at the second layer was the alternative. It would close the
top-level namespace the model keeps open (pinned by
`test_v3_array_schema_allows_unknown_extension_fields`), and treat an
unknown field unlike an unknown codec, which comes back `Opaque` for
its consumer to judge. The door's reading example called
`array.parts`, which the third layer owns now; it asks this instead.

Unchanged to the 40k-document corpus: no verdict changes.

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e it receives

The registry says `offset` and `scale` are each "encoded to JSON using
the Zarr V3 fill value encoding for the input array's data type". The
entity took any JSON value and nothing asked the data type, so the
string "0" passed as a float32 scale -- the shape of the zarr-python
bug where a string zero slipped past its zero check.

`incoming_problems` asks the type that reaches the codec, which after a
`cast_value` is the type cast to, to judge each scalar as a fill value,
and refuses the codec on a type without arithmetic: the registry lists
the integer and floating-point types, so the allow-list is the two
families. A zero `scale` is not refused: the spec does not forbid one,
and only a reader that decodes every spelling of zero can say which
scalars are zero.

Against the 40k-document corpus: 19 documents valid before are invalid,
each for a scalar no data type could hold; 45 problems gained, none
lost.

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fragment quoted 4,397 fewer and 15,259 more problems among
documents invalid under both trees, counted at an earlier head; no
counting reproduces them at 9c844e2, where the duplicate-envelope and
phantom-pipeline reports were already gone. Re-derived per entry point
against c0600ee at this head: 0 invalid->valid, 21 valid->invalid (2
`must_understand: false`, 19 `scale_offset` scalars), 6,382 fewer, 655
more.

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`out_of_range: "wrap"` is defined only for integral targets with a two's
complement representation. A modelled non-integer target -- `bool`
included, which is not two's complement -- is rejected; a target out of
scope declines, since it may be an integral extension type.

Re-landed on the current entity layer. The rule is the `cast_value`
record's `problems`, and whether a type wraps is a fact each data type
states, `twos_complement`, owed by `DataTypeEntity`: registration refuses
a data type that has not decided, so there is no table of names for a
test to keep in step.

Entities also gain `name`, the spelling a document writes, distinct from
`identifier`, the key they are tabled under. They differ only for the
raw-bytes family, whose identifier is invented; the `bytes` and
`scale_offset` messages now name an `r24` as `r24` too.

Unchanged to the 40k-document corpus: 0 verdicts, 0 problems, 0 crashes.

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@d-v-b
d-v-b force-pushed the zarr-metadata/rules-wrap-blosc branch from 1c3cf93 to eab9ec7 Compare September 22, 2026 19:06

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs release notes Automatically applied to PRs which haven't added release notes zarr-metadata Specific to the zarr-metadata sub-package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant